<!DOCTYPE html>
<html lang="en-us">

<head>
  <meta charset="utf-8">
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1" />

  <link rel="icon" type="image/x-icon" href="./favicon.ico" />

  <title>zsv playground v__VERSION__</title>

  <script src="https://cdn.jsdelivr.net/npm/jquery"></script>

  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jquery.terminal/css/jquery.terminal.min.css" />
  <script src="https://cdn.jsdelivr.net/npm/jquery.terminal/js/jquery.terminal.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/jquery.terminal/js/ascii_table.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/jquery.terminal/js/less.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/jquery.terminal/js/pipe.min.js"></script>

  <script type="text/javascript">
    // https://stackoverflow.com/questions/9038625/detect-if-device-is-ios
    function iOS() {
      return [
        'iPad Simulator',
        'iPhone Simulator',
        'iPod Simulator',
        'iPad',
        'iPhone',
        'iPod'
      ].includes(navigator.platform)
        // iPad on iOS 13 detection
        || (navigator.userAgent.includes("Mac") && "ontouchend" in document)
    }

    function loadScript(src) {
      const script = document.createElement('script');
      script.onerror = (e) => {
        console.error(`Failed to load script! [${e.target.src}]`);
      };
      script.onload = (e) => {
        console.log(`Script loaded successfully! [${e.target.src}]`);
      };
      document.currentScript.parentNode.insertBefore(script, document.currentScript);
      script.defer = true;
      script.src = src;
    }

    loadScript(iOS() ? 'non-simd/cli.em.js' : 'cli.em.js');

    var zsvAutocompletionsPopulated = false;
    var zsvAutocompletions = {};
    var zsvFlags = [];
    var zsvCommands = [];

    function parseFlagsAndCommands(output) {
      if (output.match(/^  -.*:/)) /* flags */ {
        const tokens = output.trim().split(' ');
        const flag = tokens[0].endsWith(':') ? tokens[0].slice(0, -1) : tokens[0];
        zsvFlags.push(...flag.split(','));
      } else if (output.match(/^  \w/)) /* commands */ {
        if (output.startsWith('zsv') === false) {
          const tokens = output.trim().split(' ');
          if (tokens[0] !== 'zsv') {
            const cmd = tokens[0].endsWith(':') ? tokens[0].slice(0, -1) : tokens[0];
            zsvCommands.push(cmd);
          }
        }
      }
    }

    function populateMainFlagsAndSubcommands() {
      zsvFlags = [];
      zsvCommands = [];
      Module.callMain(['help']);

      Object.assign(zsvAutocompletions, { globalFlags: zsvFlags });
      Object.assign(zsvAutocompletions, { help: [] });
      Object.assign(zsvAutocompletions, { version: [] });
      Object.assign(zsvAutocompletions, { thirdparty: [] });

      for (const subcommand of zsvCommands) {
        zsvFlags = [];
        zsvCommands = [];
        Module.callMain(['help', subcommand]);
        Object.assign(zsvAutocompletions, { [subcommand]: zsvFlags });
      }

      zsvAutocompletionsPopulated = true;
    }

    function autocomplete(string, command) {
      const mainCommands = Object.keys(zsvAutocompletions).filter(cmd => cmd != 'globalFlags');

      if (command.match(/^zsv\s+$/) || command.match(/^zsv\s+\w+$/)) {
        return mainCommands;
      }

      if (command.match(/^zsv\s+help\s+$/) || command.match(/^zsv\s+help\s+\w+$/)) {
        return mainCommands.filter(cmd => !['help', 'version', 'thirdparty'].includes(cmd));
      }

      if (command.match(/^zsv\s+\w+\s+$/)) {
        const subcommand = command.match(/^zsv\s+(\w+)\s+/)[1];
        if (['help', 'version', 'thirdparty'].includes(subcommand)) {
          return [];
        } else {
          return getFiles();
        }
      } else if (!string.match(/^-{1,2}/) && command.match(/^zsv\s+\w+\s+.*\w+$/)) {
        return getFiles();
      }

      if (string.match(/^-{1,2}/) || string.match(/^-{1,2}\w+/)) {
        const subcommand = command.match(/^zsv\s+(\w+)/)[1];
        const flags = zsvAutocompletions[subcommand];
        if (['prop', 'rm', 'jq'].includes(subcommand)) {
          return flags;
        } else {
          return flags.concat(zsvAutocompletions['globalFlags']);
        }
      }

      if (string === '' && command.match(/^zsv\s+\w+\s+.*/)) {
        return getFiles();
      }

      return [];
    }

    var Module = {
      print: (() => {
        return (...args) => {
          const output = args.join(' ');
          if (zsvAutocompletionsPopulated) {
            $('body').terminal().echo(output, { flush: false, exec: false });
          } else {
            parseFlagsAndCommands(output);
          }
        };
      })(),
      printErr: (() => {
        return (...args) => {
          const output = args.join(' ');
          $('body').terminal().error(output);
        };
      })(),
    };

    Module['onRuntimeInitialized'] = () => {
      console.log('Initializing zsv playground, please wait...');

      const root = '/home/zsv';
      FS.mkdir(root);
      FS.chdir(root);
      FS.writeFile('sample.csv', 'a,b,c\n1,2,3\n4,5,6\n7,8,9');

      // Populate autocompletions
      populateMainFlagsAndSubcommands();

      console.log('zsv playground initialized successfully!');
    }

    function getFiles() {
      const listing = FS.readdir('.');
      return listing.filter((entry) => (entry !== '.' && entry !== '..'));
    }

    async function loadFiles(event) {
      const files = event.target.files;
      for (const file of files) {
        const reader = new FileReader();
        reader.onloadend = () => {
          const bytes = new Uint8Array(reader.result);
          FS.writeFile(file.name, bytes);
        };
        reader.readAsArrayBuffer(file);
        $('body').terminal().echo(`Loaded: ${file.name}`);
      }
    }
  </script>
</head>

<body>
  <input id="load" type="file" accept=".csv" multiple style="display: none;" onchange="loadFiles(event);" />

  <script type="text/javascript">
    // Logo generated from:
    // https://patorjk.com/software/taag/#p=display&f=Doom&t=zsv%20playground
    // Replaced backticks with single quotes
    const logo = String.raw`
[[;red;]Welcome to     ] [[;blue;]      | |                                           | |]
[[;red;] _________   __] [[;blue;] _ __ | | __ _ _   _  __ _ _ __ ___  _   _ _ __   __| |]
[[;red;]|_  / __\ \ / /] [[;blue;]| '_ \| |/ _' | | | |/ _' | '__/ _ \| | | | '_ \ / _' |]
[[;red;] / /\__ \\ V / ] [[;blue;]| |_) | | (_| | |_| | (_| | | | (_) | |_| | | | | (_| |]
[[;red;]/___|___/ \_/  ] [[;blue;]| .__/|_|\__,_|\__, |\__, |_|  \___/ \__,_|_| |_|\__,_|]
[[;blue;]                | |             __/ | __/ |]                            
[[;blue;]                |_|            |___/ |___/ ]                         :-)
`;
    const greetings = logo +
      "\n" +
      "Version: __VERSION__\n" +
      "GitHub repository: https://github.com/liquidaty/zsv\n" +
      "Report any issues here: https://github.com/liquidaty/zsv/issues\n" +
      "\n" +
      "To get started, run [[;blue;]zsv help].\n" +
      "To list all supported commands, run [[;blue;]help].\n"
      ;

    function info() {
      $('body').terminal().echo(greetings);
    }

    const playgroundCommands = {
      info: {
        description: 'show playground info',
        usage: 'info'
      },
      help: {
        description: 'show help',
        usage: 'help'
      },
      clear: {
        description: 'clear screen',
        usage: 'clear'
      },
      load: {
        description: 'load CSV file(s) from disk (overwrite mode)',
        usage: 'load'
      },
      save: {
        description: 'save file(s) to disk',
        usage: 'save <filename> ...'
      },
      download: {
        description: 'download file(s) from URL(s) (overwrite mode)',
        usage: 'download <url> ...'
      },
      ls: {
        description: 'list current directory (/home/zsv/)',
        usage: 'ls'
      },
      rm: {
        description: 'remove file(s) (force mode)',
        usage: 'rm <filename> ...'
      },
      less: {
        description: 'view/search content of a file or stdin efficiently',
        usage: 'less [<filename>]'
      },
      config: {
        description: 'show/enable/disable playground configurations',
        usage: 'config [enable|disable <config-name>]'
      },
      zsv: {
        description: 'run zsv CLI app',
        usage: 'zsv <command> [arguments]'
      }
    };

    function help() {
      const header = ['[[;red;]command]', '[[;red;]usage]', '[[;red;]description]'];
      const help = Object.entries(playgroundCommands).map(([key, value]) => {
        return [
          `[[;yellow;]${key}]`,
          `${value.usage}`,
          `${value.description}`
        ];
      });
      help.unshift(header);
      $('body').terminal().echo(ascii_table(help, true));

      const notes = [
        "[[;red;]NOTES:]",
        "- For iOS, SIMD support is not available for `zsv`.",
        "- The `zsv sheet` command is not available in the playground.",
        "- Each browser tab is a new session i.e. nothing is retained.",
        "- Storing large `zsv` output to a file is much faster than STDOUT.",
        "  Example: `zsv echo -L 100000 worldcitiespop_mil.csv -o w100k.csv`",
        "- For navigating the large files, use `less` command.",
        "  Example: `less worldcitiespop_mil.csv`",
        "  For the large `zsv` output, pipe it to `less` command.",
        "  Example: `zsv 2json w100k.csv | less`",
        "- Configure large outputs to be truncated using `config` command.",
        "  Example: `config enable zsv_truncate_output`",
        "- Run `clear` command often if the terminal feels a bit sluggish.",
        "  It may happen over time while working with large outputs to STDOUT.",
      ];
      $('body').terminal().echo(notes.join('\n').replace(/`([^`]+)`/g, '`[[;yellow;]$1]`'));
    }

    function load() {
      document.getElementById('load').click();
    }

    function save(files) {
      for (const file of files) {
        if (FS.analyzePath(file).exists) {
          const content = FS.readFile(file);

          const a = document.createElement('a');
          a.download = file;
          a.href = URL.createObjectURL(new Blob([content], { type: 'application/octet-stream' }));
          a.style.display = 'none';
          document.body.appendChild(a);
          a.click();

          setTimeout(() => {
            document.body.removeChild(a);
            URL.revokeObjectURL(a.href);
          }, 2000);
        } else {
          $('body').terminal().error(`save: file not found [${file}]`);
        }
      }
    }

    function download(urls) {
      for (const url of urls) {
        const filename = url.split('/').pop();
        const xhr = new XMLHttpRequest();
        xhr.open('GET', url);
        xhr.responseType = 'blob';
        xhr.onload = () => {
          if (xhr.status && xhr.status === 200) {
            const reader = new FileReader();
            reader.onload = () => {
              const bytes = new Uint8Array(reader.result);
              FS.writeFile(filename, bytes);
            };
            reader.readAsArrayBuffer(xhr.response);
            $('body').terminal().echo(`Downloaded: ${filename}`);
          } else {
            $('body').terminal().error(`download: failed with ${xhr.status} [${url}]`);
          }
        };
        xhr.onerror = () => {
          $('body').terminal().error(`download: failed with ${xhr.status} [${url}]`);
        };
        xhr.send();
      }
    }

    function ls() {
      const files = getFiles().join('  ');
      if (files.length !== 0) {
        $('body').terminal().echo(files);
      }
    }

    function rm(files) {
      for (const file of files) {
        if (FS.analyzePath(file).exists) {
          FS.unlink(file);
        } else {
          $('body').terminal().error(`rm: file not found [${file}]`);
        }
      }
    }

    async function lessWithStdin() {
      var header = `[[;red;]Type your content here.]\n`;
      header += `[[;red;]Press SHIFT+ENTER for a newline.]\n`;
      header += `[[;red;]Press ENTER to finish.]\n`;
      await $('body').terminal().read(header).then((stdin) => {
        if (stdin.length > 0) {
          $('body').terminal().less(stdin);
        }
      });
    }

    function less(args) {
      if (args.length === 0) {
        lessWithStdin();
      } else if (args.length === 1) {
        const file = args[0];
        if (FS.analyzePath(file).exists) {
          const data = FS.readFile(file);
          const text = new TextDecoder('utf-8').decode(data);
          $('body').terminal().less(text);
        } else {
          $('body').terminal().error(`less: file not found [${file}]`);
        }
      } else {
        $('body').terminal().error('less: too many arguments');
      }
    }

    function dumpOutputWithTruncatedBytes() {
      const maxBytes = 4 * 1024;
      const buffer = $('body').terminal().get_output_buffer();
      const bufferLen = buffer.length;
      if (bufferLen > 0) {
        $('body').terminal().clear_buffer();
        var truncated = buffer.slice(0, maxBytes);
        truncated = truncated.slice(0, truncated.lastIndexOf('\n'));
        $('body').terminal().echo(truncated);
        if (bufferLen > maxBytes) {
          $('body').terminal().echo(`[[;yellow;]... truncated ${bufferLen - truncated.length} bytes]`);
        }
      }
    }

    function dumpOutputWithTruncatedLines() {
      const maxLines = 100;
      const lines = $('body').terminal().get_output_buffer().split('\n');
      if (lines.length > 0) {
        $('body').terminal().clear_buffer();
        const truncated = lines.slice(0, maxLines);
        $('body').terminal().echo(truncated);
        if (lines.length > maxLines) {
          $('body').terminal().echo(`[[;yellow;]... truncated ${lines.length - truncated.length} lines]`);
        }
      }
    }

    class Configuration {
      constructor() {
        this.actions = {
          enable: (name) => { this.configs[name] = true; },
          disable: (name) => { this.configs[name] = false; },
        };
        this.configs = {
          zsv_print_elapsed_time: false,
          zsv_truncate_output: false,
        };
      }

      isZsvPrintElapsedTimeEnabled() {
        return this.configs.zsv_print_elapsed_time;
      }

      isZsvTruncateOutputEnabled() {
        return this.configs.zsv_truncate_output;
      }

      validate(action, name) {
        if (action === undefined) {
          $('body').terminal().error(`config: missing action`);
          return false;
        } else if (!this.actions.hasOwnProperty(action)) {
          $('body').terminal().error(`config: invalid action [${action}]`);
          return false;
        } else if (name === undefined) {
          $('body').terminal().error(`config: missing config name`);
          return false;
        } else if (!this.configs.hasOwnProperty(name)) {
          $('body').terminal().error(`config: invalid config name [${name}]`);
          return false;
        }
        return true;
      }

      apply(action, name) {
        if (this.validate(action, name)) {
          this.actions[action](name);
          $('body').terminal().echo(`config: [[;yellow;]${name}] [updated]`);
        }
      }

      show() {
        const header = ['[[;red;]config]', '[[;red;]status]'];
        const entries = Object.entries(this.configs).map(([key, value]) => {
          return [`${key}`, `${value ? '[[;green;]enabled]' : 'disabled'}`];
        });
        entries.unshift(header);
        $('body').terminal().echo(ascii_table(entries, true));
      }

      autocomplete(string, command) {
        if (command.match(/^config\s+$/) || command.match(/^config\s+\w+$/)) {
          return Object.keys(this.actions);
        }

        if (command.match(/^config\s+(enable|disable)\s+$/) || command.match(/^config\s+(enable|disable)\s+\w+$/)) {
          return Object.keys(this.configs);
        }

        return [];
      }
    }

    const configuration = new Configuration();

    function config(args) {
      if (args.length === 0) {
        configuration.show();
      } else if (args.length > 2) {
        $('body').terminal().error('config: too many arguments');
      } else {
        const action = args[0];
        const name = args[1];
        configuration.apply(action, name);
      }
    }

    function zsv(args) {
      const start = performance.now();
      const exitCode = Module.callMain(args);
      if (exitCode === 0) {
        if (configuration.isZsvTruncateOutputEnabled()) {
          // dumpOutputWithTruncatedLines();
          dumpOutputWithTruncatedBytes();
        } else {
          $('body').terminal().flush();
        }
      }
      const end = performance.now();
      const elapsed = (end - start).toFixed(2);
      const command = "zsv " + args.slice(1).join(' ');
      const status = `>> ${command} [exit code: ${exitCode}, elapsed time: ${elapsed} ms]`;
      if (configuration.isZsvPrintElapsedTimeEnabled()) {
        $('body').terminal().error(status);
      }
      console.log(status);
    }

    $('body').terminal(function (command) {
      const parsedCommand = $.terminal.parse_command(command);
      const name = parsedCommand.name;
      const args = parsedCommand.args.map(String);
      switch (name) {
        case 'info':
          info();
          break;

        case 'help':
          help();
          break;

        case 'load':
          load();
          break;

        case 'save':
          save(args);
          break;

        case 'download':
          download(args);
          break;

        case 'ls':
          ls();
          break;

        case 'rm':
          rm(args);
          break;

        case 'less':
          less(args);
          break;

        case 'config':
          config(args);
          break;

        case 'zsv':
          zsv(args);
          break;

        case '':
          break;

        default:
          this.error(`Unsupported command! [${name}]`);
          break;
      }
    }, {
      checkArity: false,
      greetings: greetings,
      prompt: "[[;lightgreen;]zsv@playground$] ",
      completion: function (string, callback) {
        const command = this.get_command().trimLeft();
        if (command.match(/^(info|help|load|ls|download) /)) {
          callback([]);
        } else if (command.match(/^save /) || command.match(/^rm /) || command.match(/^less /)) {
          callback(getFiles());
        } else if (command.startsWith('config')) {
          callback(configuration.autocomplete(string, command));
        } else if (command.startsWith('zsv')) {
          callback(autocomplete(string, command));
        } else {
          callback(Object.keys(playgroundCommands));
        }
      },
      pipe: true,
    });
  </script>
</body>

</html>
